import { createProps, setProperty } from '../../js/createProps.js'
import breakpoint from '../../js/breakpoints.js'
import { makeId, setAttributes, debounce, setCss } from '../../js/utils.js'
import { isSafari } from '../../js/userAgents.js'
// import { getOppositeContrast } from '../../js/contrasts.js'
// import dispatchEvent from '../../js/events.js'
import { BROWSER_CONTEXT, CONTRAST_LIGHT } from '../../js/constants.js'
class Slider extends HTMLElement {
constructor() {
super()
this.classList.add('u-slider')
// Default options
this.#setDefaultOptions()
// Events
this.boundPrevClick = this.#onPrevClick.bind(this)
this.boundNextClick = this.#onNextClick.bind(this)
this.boundNextClick = this.#onNextClick.bind(this)
this.boundNavClick = this.#onNavClick.bind(this)
this.boundScroll = this.#onScroll.bind(this)
// this.boundStartDragAndDrop = this.#onStartDragAndDrop.bind(this)
// this.boundStopDragAndDrop = this.#onStopDragAndDrop.bind(this)
// this.boundDragAndDrop = this.#onDragAndDrop.bind(this)
this.boundOnResize = debounce(this.#onResize.bind(this), 250)
}
#setDefaultOptions() {
// this.contrast = CONTRAST_LIGHT -> todo: we need to manage contrast differently, may be not needed anymore
this.defaultPosition = 0
this.displayButtons = true
this.displayNav = true
this.displayCounter = false
this.displayGradients = false
this.itemsPerSlide = 1
this.overflow = false
this.overflowOpacity = 0.25
this.flexAuto = false
this.justify = null
}
connectedCallback() {
// We need an id for event dispatcher
if (!this.id || this.id === 'undefined') {
this.id = `slider_${makeId(5)}`
}
}
static get observedAttributes() {
return ['data']
}
attributeChangedCallback(name) {
if (name === 'data') {
const temp = this.getAttribute('data')
if (temp !== '') {
this.#loadData()
}
}
if (name === 'slot') {
this.initSlider()
}
}
#loadData() {
createProps(this, true)
this.#render()
this.#setProperties()
this.initSlider()
}
#setProperty(key, value) {
setProperty(this, key, value)
}
#setProperties() {
const { options, a11y_labels } = this.data
// Create refs to html elements
this.container = this.querySelector('.slider-container')
this.wrapper = this.querySelector('.slider-wrapper')
this.carousel = this.querySelector('.slider-carousel')
// Set slider properties
this.currentIndex = 0
this.a11y_labels = a11y_labels
// Get right options depending on breakpoint
if (breakpoint.is.desktop) {
this.options = options.desktop
} else if (breakpoint.is.tablet) {
this.options = options.tablet
} else {
this.options = options.mobile
}
// Set options
for (const property in this.options) {
this.#setProperty(property, this.options[property])
}
// If counter is displayed we force buttons and pagination to false
if (this.displayCounter) {
this.displayNav = this.displayButtons = false
}
// Overflow needs a class
if (this.overflow) {
this.container.classList.add('overflow')
} else {
this.container.classList.remove('overflow')
}
// Contrast needs an attribute
// todo: we need to manage contrast diffrently, may be not needed anymore
// this.buttonsContrast = getOppositeContrast(this.contrast)
// this.setAttribute('contrast', this.contrast)
// Set CSS properties for non dynamic options
this.#setCssSlideMinHeight()
this.#setCssSliderGap()
this.#setCssPlaceholder()
}
/** Public Method **/
initSlider() {
// Get an updated number of items (if async we run again)
this.slidesElements = this.querySelectorAll('.slider-carousel > li:not(.safari-fix)')
this.itemNumber = this.slidesElements.length
if (this.itemNumber <= 0) {
// If there is no elements, do not initialize
return;
}
// For asynchronous initSlider() we need to reset first itemWidth and itemPerSlide to get the original values before other calculations
this.#setProperty('item_width', this.options['item_width'])
this.#setProperty('items_per_slide', this.options['items_per_slide'])
// If itemsPerSlide is 'auto' we need to calculate it's real value based on itemWidth or scrollWidth / itemNumber
// We also need to set the CSS --item-width variable to handle the calculation correctly
if (this.itemsPerSlide === 'auto') {
this.#calculateItemsPerSlide()
}
this.#setCssSlideWidth()
this.#setCssItemsPerSlide()
this.#setCssFlexAuto()
// Other properties that can be set only after the rest
this.maxSlides = this.itemNumber - this.itemsPerSlide
// Finish rendering
this.#renderButtons()
this.#renderNav()
this.#renderCounter()
this.#renderGradients()
// Init methods in this order !
this.#applyCenteredLayout()
this.#getSliderPositions()
this.#getOpacityParams()
this.#setOpacity()
this.#updateStates()
this.#bindEvent()
this.#gotoDefaultPosition()
// Render this safariFix at the end because we don't want the extra li to be considered in sliderElements
// this.#renderSafariFix()
}
#render() {
this.innerHTML = `
${this.slot}
`
}
#renderButtons() {
// Important: if maxSlides <= 0 it means we don't have scroll, so we don't need buttons
const hasNoButton = !this.displayButtons || this.maxSlides <= 0
if (hasNoButton) {
// Needed if we reinit
if (this.prevButton && this.nextButton) {
this.prevButton.remove()
this.nextButton.remove()
}
return
}
// Check if button exists already (for async content in order to avoid creating two occurrences)
if (!this.prevButton) {
this.prevButton = this.#renderButton('slider-prev')
}
if (!this.nextButton) {
this.nextButton = this.#renderButton('slider-next')
}
}
#renderButton(position, renderInCounter = false) {
// const buttonContrast = renderInCounter ? this.contrast : this.buttonsContrast
const isPrev = position === 'slider-prev'
const label = isPrev ? 'prev' : 'next'
const CustomElement = window.customElements.get("u-navbtn")
const buttonEl = new CustomElement()
buttonEl.classList.add(position)
setAttributes(buttonEl, {
variant: 'primary',
// contrast: buttonContrast,
size: 'md',
icon_right: isPrev ? '24/ui/chevron-left' : '24/ui/chevron-right',
aa_label: this.a11y_labels[label]
})
if (!renderInCounter) {
isPrev ? this.container.prepend(buttonEl) : this.container.append(buttonEl)
} else {
isPrev ? this.counter.prepend(buttonEl) : this.counter.append(buttonEl)
}
return buttonEl
}
#renderNav() {
if (!this.displayNav || this.maxSlides === 0) {
// Needed if we reinit
if (this.nav) {
this.nav.remove()
}
return
}
// Check if nav exists already (for async content in order to avoid creating two occurrences)
if (this.nav) {
this.nav.remove()
}
const nav = document.createElement('nav')
let bullets = ''
for (let i = 0; i <= this.maxSlides; i++) {
bullets += this.#renderBullet(i)
}
nav.innerHTML = `${bullets}`
this.appendChild(nav)
this.nav = this.querySelector('nav')
this.navElements = this.querySelectorAll('nav button')
}
#renderBullet(index) {
return ``
}
#renderCounter() {
if (!this.displayCounter || this.maxSlides === 0) {
// Needed if we reinit
if (this.counter) {
this.counter.remove()
}
return
}
// Check if counter exists already (for async content in order to avoid creating two occurrences)
if (this.counter) {
this.counter.remove()
}
this.counter = document.createElement('div')
this.counter.classList.add('slider-counter')
this.counter.classList.add('t-sm-regular')
this.counter.setAttribute('aria-hidden', 'true')
this.counter.innerHTML = `${
this.currentIndex + 1
}/${this.maxSlides + 1}`
this.prevButton = this.#renderButton('slider-prev', true)
this.nextButton = this.#renderButton('slider-next', true)
this.appendChild(this.counter)
}
#renderGradients() {
if (!this.displayGradients || this.overflow) {
return
}
this.#setCss('--gradients-width', this.gradientsWidth, true)
this.#setCss('--gradients-color', this.gradientsColor)
this.#setCss('--gradients-color-transparent', this.gradientsColor + '00')
this.container.classList.add('hasGradients')
}
#renderSafariFix() {
// In safari, both Desktop and iOS, the scroll doesn't reach the end, because the scroll-padding on the right side seems to not been taken into account
// Same bug is happening in Chrome on iOS (seems to use Safari webkit rendering)
// The fix is to render an extra
with the size equal to the scroll-padding
if (!this.overflow || !isSafari()) {
return
}
// Check if safariFix exists already (for async content in order to avoid creating two occurences)
if (!this.safariFix) {
this.safariFix = document.createElement('li')
this.safariFix.classList.add('safari-fix')
this.safariFix.setAttribute('aria-hidden', true)
this.carousel.appendChild(this.safariFix)
}
const safariFixWidth = (this.paddingCorrection - this.sliderGap) / BROWSER_CONTEXT
this.safariFix.style.setProperty('width', safariFixWidth + 'rem')
}
#calculateItemsPerSlide() {
// For asynchronous slider content, if initSlider() is called twice, we need to remove first this class
this.carousel.classList.remove('no-slides')
let itemWidth = this.itemWidth
// If itemWidth is not provided or 'auto', we need to calculate an average width for items that doesn't have fixed width
if (this.itemWidth === undefined || this.itemWidth === 'auto') {
itemWidth =
(this.carousel.scrollWidth - this.sliderGap * (this.itemNumber - 1)) /
this.itemNumber
}
this.itemsPerSlide = Math.ceil(this.carousel.offsetWidth / itemWidth)
}
#getSliderPositions() {
this.sliderPositions = []
this.maxScroll = this.carousel.scrollWidth - this.carousel.offsetWidth
// We need to manage the first item initial position in case the slides are visible on each side (equivalent to scroll-padding CSS of ul.carousel)
const firstItemOffsetLeft = this.slidesElements[0].offsetLeft
this.querySelectorAll('li').forEach(element => {
this.sliderPositions.push(element.offsetLeft - firstItemOffsetLeft)
})
}
#setCss(name, value, isRem = false) {
setCss(this, name, value, isRem)
}
#setCssSlideWidth() {
if (this.itemWidth === 'auto') {
const itemWidthAuto =
(this.container.clientWidth - (this.itemsPerSlide - 1) * this.sliderGap) /
this.itemsPerSlide
this.#setCss('--item-width', itemWidthAuto, true)
} else if (this.itemWidth) {
this.#setCss('--item-width', this.itemWidth, true)
}
}
#setCssSlideMinHeight() {
if (this.slideMinHeight === 'auto') {
this.#setCss('--slide-min-height', 'auto')
} else if (this.slideMinHeight !== undefined) {
this.#setCss('--slide-min-height', this.slideMinHeight, true)
}
}
#setCssSliderGap() {
if (this.sliderGap || this.sliderGap === 0) {
this.#setCss('--slider-gap', this.sliderGap.toString(), true)
}
}
#setCssFlexAuto() {
if (this.flexAuto) {
this.carousel.classList.add('flex-auto')
}
}
#setCssNoSlidesJustify() {
this.#setCss('--no-slides-justify', this.justify)
}
#setCssPlaceholder() {
this.#setCss('--placeholder-background', this.placeholderBackground)
}
#setCssItemsPerSlide() {
this.#setCss('--items-per-slide', this.itemsPerSlide)
}
#applyCenteredLayout() {
if (this.maxSlides > 0) {
return
}
// If maxSlides is 0 or negative, it means that we have less items than needed to fulfill the carousel width
// So to center the items (for example the bubbles when only 3) we need to apply a justify-content: --no-slides-justify fix
this.carousel.classList.add('no-slides')
this.#setCssNoSlidesJustify()
}
/** Public Method **/
updateSlider(index, instantly = false) {
this.#getSliderPositions()
this.goToSlide(parseInt(index), instantly)
}
#updateStates() {
this.#setActiveButtons()
this.#setActiveBullet()
this.#setCounter()
this.#setActiveSlides()
}
#setActiveButtons() {
if ((!this.displayButtons || this.maxSlides <= 0) && !this.displayCounter) {
return
}
// First we remove all hide classes (because with pagination we can move from more than 2 slides)
this.prevButton.classList.remove('isHidden')
this.nextButton.classList.remove('isHidden')
// Then we reset hide class on the right button
if (this.currentIndex === 0) {
this.prevButton.classList.add('isHidden')
} else if (this.currentIndex === this.maxSlides) {
this.nextButton.classList.add('isHidden')
}
}
#setActiveBullet() {
if (!this.displayNav || this.maxSlides <= 0) {
return
}
this.navElements.forEach(item => {
item.classList.remove('active')
})
this.navElements[this.currentIndex].classList.add('active')
}
#setCounter() {
if (!this.displayCounter || this.maxSlides === 0) {
return
}
this.counter.querySelector('.current').textContent = this.currentIndex + 1
}
#setActiveSlides() {
// If we have no items overflowing on each side, we don't need to manage inactive slides
if (!this.overflow) {
return
}
this.slidesElements.forEach(element => {
element.classList.add('inactive')
})
let tempIndex = this.currentIndex
for (let i = 0; i < this.itemsPerSlide; i++) {
if (this.slidesElements[tempIndex + i]) {
this.slidesElements[tempIndex + i].classList.remove('inactive')
}
}
}
/** Public Method **/
goToSlide(index, instantly = false) {
if (index !== undefined) {
this.currentIndex = index
}
// If index is over maxSlides, it means we reached out the end of scroll, so we don't need to update states anymore
if (index < this.maxSlides) {
this.#updateStates()
}
// We scroll to the right position (scrollTo duration is approx 750ms)
this.carousel.scrollTo({
left: this.sliderPositions[this.currentIndex],
behavior: instantly ? 'auto' : 'smooth'
})
}
#gotoDefaultPosition() {
if (this.defaultPosition === 0 || this.defaultPosition > this.maxSlides + 1) {
return
}
this.goToSlide(this.defaultPosition, true)
}
#onScroll() {
// Clear our timeout throughout the scroll
if (this.isScrolling) {
clearTimeout(this.isScrolling)
}
this.#setOpacity()
// Set a timeout to run after scrolling ends
this.isScrolling = setTimeout(() => {
const carouselScrollLeft = Math.round(this.carousel.scrollLeft)
// Ideal use case (exact pixel value)
let index = this.sliderPositions.indexOf(carouselScrollLeft)
// Sometimes the scrollLeft value can be +1 ou -1 pixels, so if not found, we need to search for +1 or -1
if (index === -1) {
index = this.sliderPositions.indexOf(carouselScrollLeft - 1)
}
if (index === -1) {
index = this.sliderPositions.indexOf(carouselScrollLeft + 1)
}
// For items with flexible width (flex_auto) the last position of the scroll can not match an exact SliderPositions
if (carouselScrollLeft === this.maxScroll) {
index = this.maxSlides
}
if (index >= 0) {
this.#switchBullets(index, true)
}
}, 100)
}
#getOpacityParams() {
if (!this.overflow) {
return
}
// Set properties used for slide opacity calculation in #setOpacity()
this.paddingCorrection = parseInt(
getComputedStyle(this.carousel).getPropertyValue('padding-left')
)
this.itemWidthAdjusted = this.slidesElements[0].offsetWidth + this.sliderGap
}
#setOpacity() {
if (!this.overflow) {
return
}
const scrollX = this.carousel.scrollLeft
// if (DEBUG) this.debugScrollX.innerText = scrollX
this.slidesElements.forEach((item, index) => {
// Adjust item offsetLeft because of slider padding on each side
const itemOffsetLeft = item.offsetLeft - this.paddingCorrection
// Adjust item offsetLeft to add itemWidth + sliderGap
let itemX = itemOffsetLeft + this.itemWidthAdjusted
// Determine if we need to calculate left or right items
let percentage = 100
if (scrollX > itemX) {
// Avoid calculation if item is outside on left
// if (DEBUG) direction = 'outside left'
percentage = 0
} else if (scrollX >= this.itemWidthAdjusted * index) {
// Do left calculations
// if (DEBUG) direction = 'left'
percentage = Math.round(((itemX - scrollX) / this.itemWidthAdjusted) * 100)
} else if (scrollX < itemX - (1 + this.itemsPerSlide) * this.itemWidthAdjusted) {
// Avoid calculation if item is outside on right
// if (DEBUG) direction = 'outside right'
percentage = 0
} else if (scrollX <= itemX - this.itemsPerSlide * this.itemWidthAdjusted) {
// Do right calculations
// if (DEBUG) direction = 'right'
percentage = Math.round(
(this.itemsPerSlide + 1) * 100 -
((itemX - scrollX) / this.itemWidthAdjusted) * 100
)
}
const opacityPercentage = percentage / 100
let finalOpacity =
Math.round(
(opacityPercentage * (1 - this.overflowOpacity) + this.overflowOpacity) * 100
) / 100
// if (DEBUG) item.positionDebug.innerText = direction
// if (DEBUG) item.opacityDebug.innerText = finalOpacity
item.firstElementChild.style.setProperty('opacity', finalOpacity)
})
}
#switchBullets(index, alreadyScrolled = false) {
if (!this.slidesElements[index]) {
return
}
this.currentIndex = index
// only fires goToSlide if user clicks on pagination buttons
if (!alreadyScrolled) {
this.goToSlide()
} else {
this.#updateStates()
}
}
#onPrevClick() {
if (this.currentIndex > 0) {
this.currentIndex--
}
this.goToSlide()
}
#onNextClick() {
if (this.currentIndex < this.maxSlides) {
this.currentIndex++
}
this.goToSlide()
}
#onNavClick(e) {
this.currentIndex = parseInt(e.target.dataset.item)
this.#setActiveBullet()
this.#switchBullets(this.currentIndex)
}
// #dispatchReady() {
// dispatchEvent({
// eventName: EVENT_SLIDER_READY,
// args: { id: this.id },
// element: this
// })
// }
#onResize() {
this.#setDefaultOptions()
this.#setProperties()
this.initSlider()
}
// #onStartDragAndDrop(e) {
// console.log('start drag')
// this.carousel.style.scrollSnapType = 'initial'
// this.carousel.addEventListener('mousemove', this.boundDragAndDrop)
// this.startDragPosition = {
// // The current scroll
// left: this.carousel.scrollLeft,
// // Get the current mouse position
// x: e.clientX
// }
// console.log('startDragPosition', this.startDragPosition.left, this.startDragPosition.x)
// }
//
// #onStopDragAndDrop() {
// console.log('stop drag')
// this.carousel.removeEventListener('mousemove', this.boundDragAndDrop)
// this.carousel.style.scrollSnapType = 'x mandatory'
// }
//
// #onDragAndDrop(e) {
// // How far the mouse has been moved
// const dx = e.clientX - this.startDragPosition.x;
// console.log('dx', dx)
// // Scroll the element
// this.carousel.scrollLeft = this.startDragPosition.left - dx;
// console.log('scrollLeft', this.startDragPosition.left - dx, this.carousel.scrollLeft)
// }
#bindEvent() {
this.#unbindEvent()
this.carousel.addEventListener('scroll', this.boundScroll)
// this.carousel.addEventListener('mousedown', this.boundStartDragAndDrop)
// this.carousel.addEventListener('mouseup', this.boundStopDragAndDrop)
if (this.prevButton) {
this.prevButton.addEventListener('click', this.boundPrevClick)
}
if (this.nextButton) {
this.nextButton.addEventListener('click', this.boundNextClick)
}
if (this.navElements) {
this.navElements.forEach(element => {
element.addEventListener('click', this.boundNavClick)
})
}
window.addEventListener('resize', this.boundOnResize)
}
#unbindEvent() {
this.carousel.removeEventListener('scroll', this.boundScroll)
if (this.prevButton) {
this.prevButton.removeEventListener('click', this.boundPrevClick)
}
if (this.nextButton) {
this.nextButton.removeEventListener('click', this.boundNextClick)
}
if (this.navElements) {
this.navElements.forEach(element => {
element.removeEventListener('click', this.boundNavClick)
})
}
window.removeEventListener('resize', this.boundOnResize)
}
disconnectedCallback() {
this.#unbindEvent()
}
}
customElements.get('u-slider') || customElements.define('u-slider', Slider)
export default Slider